diff options
Diffstat (limited to 'app/api/data-room/[projectId]/download-folder/[folderId]/route.ts')
| -rw-r--r-- | app/api/data-room/[projectId]/download-folder/[folderId]/route.ts | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts new file mode 100644 index 00000000..bba7066f --- /dev/null +++ b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts @@ -0,0 +1,289 @@ +// app/api/data-room/[projectId]/download-folder/[folderId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import archiver from 'archiver'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq, and } from "drizzle-orm"; + +interface FileWithPath { + file: any; + absolutePath: string; + relativePath: string; +} + +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; folderId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + // 폴더 정보 가져오기 + const folder = await db.query.fileItems.findFirst({ + where: and( + eq(fileItems.id, params.folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + if (!folder || folder.type !== 'folder') { + return NextResponse.json( + { error: '폴더를 찾을 수 없습니다' }, + { status: 404 } + ); + } + + const fileService = new FileService(); + const downloadableFiles: FileWithPath[] = []; + const unauthorizedFiles: string[] = []; + + // 재귀적으로 폴더 내 모든 파일 가져오기 및 권한 확인 + const processFolder = async ( + folderId: string, + folderPath: string = '' + ): Promise<void> => { + const items = await db.query.fileItems.findMany({ + where: and( + eq(fileItems.parentId, folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + for (const item of items) { + if (item.type === 'file') { + // 파일 권한 확인 + const hasAccess = await fileService.checkFileAccess( + item.id, + context, + 'download' + ); + + if (!hasAccess) { + // 권한이 없는 파일 기록 + unauthorizedFiles.push(path.join(folderPath, item.name)); + continue; + } + + if (!item.filePath) continue; + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + const relativePath = item.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + absolutePath = path.join(process.cwd(), 'public', item.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + downloadableFiles.push({ + file: item, + absolutePath, + relativePath: path.join(folderPath, item.name) + }); + + // 다운로드 카운트 증가 및 로그 기록 + await fileService.downloadFile(item.id, context); + } catch (error) { + console.warn(`파일을 찾을 수 없습니다: ${absolutePath}`); + } + } else if (item.type === 'folder') { + // 하위 폴더 재귀 처리 + await processFolder( + item.id, + path.join(folderPath, item.name) + ); + } + } + }; + + // 폴더 처리 시작 + await processFolder(params.folderId, folder.name); + + // 권한이 없는 파일이 있으면 다운로드 차단 + if (unauthorizedFiles.length > 0) { + return NextResponse.json( + { + error: '일부 파일에 대한 다운로드 권한이 없습니다', + unauthorizedFiles: unauthorizedFiles, + unauthorizedCount: unauthorizedFiles.length, + message: `다음 파일들에 대한 권한이 없어 폴더 다운로드가 취소되었습니다: ${unauthorizedFiles.slice(0, 5).join(', ')}${unauthorizedFiles.length > 5 ? ` 외 ${unauthorizedFiles.length - 5}개` : ''}` + }, + { status: 403 } + ); + } + + // 다운로드할 파일이 없는 경우 + if (downloadableFiles.length === 0) { + return NextResponse.json( + { error: '다운로드 가능한 파일이 없습니다' }, + { status: 404 } + ); + } + + // 파일 크기 합계 체크 (최대 500MB) + const totalSize = downloadableFiles.reduce((sum, item) => + sum + (item.file.size || 0), 0 + ); + + const maxSize = 500 * 1024 * 1024; // 500MB + if (totalSize > maxSize) { + return NextResponse.json( + { + error: `폴더 크기가 너무 큽니다 (${(totalSize / 1024 / 1024).toFixed(2)}MB). 최대 500MB까지 다운로드 가능합니다.`, + totalSize: totalSize, + maxSize: maxSize, + fileCount: downloadableFiles.length + }, + { status: 400 } + ); + } + + console.log(`📦 폴더 다운로드 시작: ${folder.name} (${downloadableFiles.length}개 파일, ${(totalSize / 1024 / 1024).toFixed(2)}MB)`); + + // ZIP 스트림 생성 + const archive = archiver('zip', { + zlib: { level: 5 } // 압축 레벨 + }); + + // 스트림을 Response로 변환 + const stream = new ReadableStream({ + start(controller) { + archive.on('data', (chunk) => controller.enqueue(chunk)); + archive.on('end', () => controller.close()); + archive.on('error', (err) => { + console.error('Archive error:', err); + controller.error(err); + }); + }, + }); + + // 파일들을 ZIP에 추가 (폴더 구조 유지) + for (const { file, absolutePath, relativePath } of downloadableFiles) { + try { + const fileBuffer = await fs.readFile(absolutePath); + archive.append(fileBuffer, { name: relativePath }); + } catch (error) { + console.error(`파일 추가 실패: ${relativePath}`, error); + } + } + + // ZIP 완료 + archive.finalize(); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', 'application/zip'); + headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(folder.name)}.zip"`); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + headers.set('X-File-Count', downloadableFiles.length.toString()); + headers.set('X-Total-Size', totalSize.toString()); + + return new NextResponse(stream, { + status: 200, + headers, + }); + + } catch (error) { + console.error('폴더 다운로드 오류:', error); + return NextResponse.json( + { error: '폴더 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 폴더 다운로드 전 권한 체크 (선택적) +export async function HEAD( + request: NextRequest, + { params }: { params: { projectId: string; folderId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse(null, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + let totalFiles = 0; + let unauthorizedCount = 0; + let totalSize = 0; + + // 재귀적으로 권한 체크 + const checkFolder = async (folderId: string): Promise<void> => { + const items = await db.query.fileItems.findMany({ + where: and( + eq(fileItems.parentId, folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + for (const item of items) { + if (item.type === 'file') { + totalFiles++; + totalSize += item.size || 0; + + const hasAccess = await fileService.checkFileAccess( + item.id, + context, + 'download' + ); + + if (!hasAccess) { + unauthorizedCount++; + } + } else if (item.type === 'folder') { + await checkFolder(item.id); + } + } + }; + + await checkFolder(params.folderId); + + const headers = new Headers(); + headers.set('X-Total-Files', totalFiles.toString()); + headers.set('X-Unauthorized-Files', unauthorizedCount.toString()); + headers.set('X-Total-Size', totalSize.toString()); + headers.set('X-Can-Download', unauthorizedCount === 0 ? 'true' : 'false'); + + return new NextResponse(null, { + status: unauthorizedCount > 0 ? 403 : 200, + headers, + }); + + } catch (error) { + console.error('권한 체크 오류:', error); + return new NextResponse(null, { status: 500 }); + } +}
\ No newline at end of file |
